diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 11:32:41 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 11:32:41 +0200 |
| commit | 18a0f9c874aaa09c0f71252dad853c23f9511ed6 (patch) | |
| tree | 93103b83f6d0057c40f0bab9c9473bf04292e9c4 /src/routes/projects/[id]/+page.svelte | |
| parent | 113415ce357a9cea1759d0452ebae6352f8fe36b (diff) | |
Add projects with routines, urgency tracking and health scores
Projects group recurring routines (chores included) with an append-only
snapshot table (id, version) so edits never rewrite history; completions
reference the exact snapshot. Pure metrics compute due dates, urgency
buckets (ok/due/late/veryLate) and 0-100 health scores per routine,
project, and globally. Home page lists projects; project dashboard sorts
routines most-urgent first with one-tap completion and interval parser
inputs (1d/2w/3m/1y).
Diffstat (limited to 'src/routes/projects/[id]/+page.svelte')
| -rw-r--r-- | src/routes/projects/[id]/+page.svelte | 395 |
1 files changed, 395 insertions, 0 deletions
diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte new file mode 100644 index 0000000..d9747a9 --- /dev/null +++ b/src/routes/projects/[id]/+page.svelte @@ -0,0 +1,395 @@ +<script lang="ts"> + import { enhance } from '$app/forms'; + import { + formatFrequency, + parseIntervalToDays, + STATUS_LABELS, + type RoutineState, + } from '$lib/shared/routine-metrics'; + + let { data, form } = $props(); + + let name = $state(''); + let frequency = $state(''); + let editingId = $state<number | null>(null); + let editName = $state(''); + let editFrequency = $state(''); + + const frequencyDays = $derived(parseIntervalToDays(frequency)); + const editFrequencyDays = $derived(parseIntervalToDays(editFrequency)); + + const dateFormatter = new Intl.DateTimeFormat('fr-FR', { + day: 'numeric', + month: 'short', + }); + + function sorted(routines: RoutineState[]): RoutineState[] { + return [...routines].sort( + (a, b) => b.overdueRatio - a.overdueRatio || a.name.localeCompare(b.name), + ); + } + + function startEdit(routine: RoutineState) { + editingId = routine.id; + editName = routine.name; + editFrequency = `${routine.frequencyDays}d`; + } + + function scoreClass(score: number): string { + if (score >= 80) return 'score score--good'; + if (score >= 50) return 'score score--mid'; + return 'score score--bad'; + } + + function statusClass(status: RoutineState['status']): string { + if (status === 'ok') return 'status status--ok'; + if (status === 'due') return 'status status--due'; + if (status === 'late') return 'status status--late'; + return 'status status--very-late'; + } +</script> + +<main> + <header class="page-header"> + <h1>{data.project.name}</h1> + <span class={scoreClass(data.healthScore)}>{data.healthScore}</span> + </header> + + <form + class="cat-add-bar" + method="POST" + action="?/addRoutine" + use:enhance={() => { + return async ({ update }) => { + name = ''; + frequency = ''; + await update({ reset: false }); + }; + }} + > + <input + class="form-input" + type="text" + name="name" + bind:value={name} + placeholder="Nouvelle routine..." + autocomplete="off" + /> + <input + class="form-input frequency-input" + class:input-invalid={frequency !== '' && frequencyDays === null} + type="text" + name="frequencyDays" + bind:value={frequency} + placeholder="2w" + autocomplete="off" + aria-label="Intervalle (ex: 1d, 2w, 3m)" + /> + <button + class="btn-accent" + type="submit" + disabled={!name.trim() || frequencyDays === null} + aria-label="Ajouter" + > + + + </button> + </form> + + {#if frequency !== '' && frequencyDays === null} + <p class="error">Intervalle invalide — ex : 1d, 2w, 3m, 1y</p> + {/if} + + {#if form?.error} + <p class="error">{form.error}</p> + {/if} + + <ul class="item-list"> + {#each sorted(data.routines) as routine (routine.id)} + <li class="item-row item-row--card routine-row"> + {#if editingId === routine.id} + <form + class="edit-form" + method="POST" + action="?/editRoutine" + use:enhance={() => { + return async ({ result, update }) => { + if (result.type === 'failure') { + // Keep the edit form open so the error is visible. + await update({ reset: false }); + return; + } + editingId = null; + await update({ reset: false }); + }; + }} + > + {#if form?.error} + <p class="error edit-error">{form.error}</p> + {/if} + <input type="hidden" name="id" value={routine.id} /> + <input + class="form-input" + type="text" + name="name" + bind:value={editName} + /> + <input + class="form-input frequency-input" + class:input-invalid={editFrequency !== '' && + editFrequencyDays === null} + type="text" + name="frequencyDays" + bind:value={editFrequency} + placeholder="2w" + autocomplete="off" + aria-label="Intervalle (ex: 1d, 2w, 3m)" + /> + <button + class="btn-primary" + type="submit" + disabled={!editName.trim() || editFrequencyDays === null} + > + OK + </button> + <button + class="btn-ghost" + type="button" + onclick={() => (editingId = null)}>✕</button + > + </form> + {:else} + <div class="routine-info"> + <span class="routine-name">{routine.name}</span> + <span class="routine-meta"> + {formatFrequency(routine.frequencyDays)} + {#if routine.lastDoneAt} + · fait le {dateFormatter.format(new Date(routine.lastDoneAt))} + {:else} + · jamais fait + {/if} + </span> + </div> + <span class={statusClass(routine.status)}> + {STATUS_LABELS[routine.status]} + {#if routine.overdueDays > 0}({routine.overdueDays} + j){/if} + </span> + <form method="POST" action="?/complete" use:enhance> + <input type="hidden" name="id" value={routine.id} /> + <button class="btn-done" type="submit">Fait</button> + </form> + <button + class="btn-ghost" + type="button" + aria-label="Modifier" + onclick={() => startEdit(routine)}>✎</button + > + <form method="POST" action="?/archiveRoutine" use:enhance> + <input type="hidden" name="id" value={routine.id} /> + <button class="btn-ghost" type="submit" aria-label="Archiver"> + × + </button> + </form> + {/if} + </li> + {/each} + </ul> + + {#if data.routines.length === 0} + <p class="empty">Aucune routine</p> + {/if} + + <footer class="page-footer"> + <form method="POST" action="?/archiveProject" use:enhance> + <button class="btn-archive" type="submit">Archiver le projet</button> + </form> + </footer> +</main> + +<style> + .page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 0; + } + + .routine-row { + flex-wrap: wrap; + padding: 0.75rem 1rem; + } + + .routine-info { + flex-grow: 1; + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.2; + } + + .routine-name { + font-weight: 500; + } + + .routine-meta { + font-size: 0.75rem; + color: var(--text-secondary); + } + + .edit-form { + display: flex; + flex-grow: 1; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + } + + .edit-error { + width: 100%; + } + + .edit-form .form-input { + flex: 1; + padding: 0.5rem 0.75rem; + } + + .frequency-input { + width: 4.5rem; + flex-shrink: 0; + } + + .input-invalid { + border-color: var(--danger); + } + + .status { + flex-shrink: 0; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; + } + + .status--ok { + background: var(--success); + color: var(--white); + } + + .status--due { + background: var(--shadow-accent); + color: var(--accent); + } + + .status--late { + background: var(--accent); + color: var(--white); + } + + .status--very-late { + background: var(--danger); + color: var(--white); + } + + .btn-done { + flex-shrink: 0; + padding: 0.5rem 0.875rem; + border: none; + border-radius: 0.625rem; + background: var(--success); + color: var(--white); + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; + } + + .btn-done:hover { + filter: brightness(1.05); + transform: translateY(-1px); + } + + .score { + flex-shrink: 0; + min-width: 2.75rem; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + text-align: center; + font-size: 0.875rem; + font-weight: 700; + color: var(--white); + } + + .score--good { + background: var(--success); + } + + .score--mid { + background: var(--accent); + } + + .score--bad { + background: var(--danger); + } + + .cat-add-bar { + display: flex; + gap: 0.5rem; + padding: 0.75rem 0; + } + + .cat-add-bar input:first-child { + flex: 1; + background: var(--surface); + min-width: 0; + } + + .btn-accent { + width: 3rem; + height: 3rem; + flex-shrink: 0; + padding: 0; + border: none; + border-radius: 0.75rem; + background: var(--accent); + color: var(--white); + font-size: 1.25rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + box-shadow: 0 1px 2px var(--shadow); + } + + .btn-accent:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 8px var(--shadow-accent-lg); + } + + .page-footer { + margin-top: 2rem; + display: flex; + justify-content: center; + } + + .btn-archive { + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 0.875rem; + text-decoration: underline; + cursor: pointer; + } + + .btn-archive:hover { + color: var(--danger); + } + + .empty { + margin-top: 1rem; + padding: 2rem; + } +</style> |
